Skip to content

Conversation

@siv2r
Copy link
Contributor

@siv2r siv2r commented Jan 3, 2026

This PR adds a BIP for the FROST (Flexible Round-Optimized Schnorr Threshold) signing protocol. The development repository is at https://github.com/siv2r/bip-frost-signing.

There already exists RFC 9591, which standardizes the two-round FROST signing protocol, but it is incompatible with Bitcoin's BIP340 X-only public keys. This BIP bridges that gap by providing a BIP340-compatible variant of FROST.

This BIP standardizes the FROST3 variant (Section 2.3 of the ROAST paper). This variant shares significant similarities with the MuSig2 signing protocol (BIP327). Accordingly, this BIP follows the core design principles of BIP327, and many sections have been directly adapted from it.

FROST key generation is out of scope for this BIP. There are sister BIPs such as ChillDKG and Trusted Dealer Generation that specify key generation mechanisms. This BIP must be used in conjunction with either of those for the full workflow from key generation to signature creation. Careful consideration has been taken to ensure the terminology in this BIP matches that of ChillDKG.

There are multiple (experimental) implementations of this specification:

  • The reference Python implementation included in this PR
  • secp256k1-zkp FROST module (yet to implement the test vectors)
  • FROST-BIP340 TODO: verify if this impl is compatible with our test vectors
  • secp256kfun (implements ChillDKG with FROST signing) TODO: verify if this impl is compatible with our test vectors

Disclosure: AI has been used to rephrase paragraphs for clarity, refactor certain sections of the reference code, and review pull requests made to the development repository.

Feedback is appreciated! Please comment on this pull request or open an issue at https://github.com/siv2r/bip-frost-signing for any feedback. Thank you!

cc @jonasnick @real-or-random @jesseposner

@siv2r
Copy link
Contributor Author

siv2r commented Jan 3, 2026

I'll fix the typos check soon

@siv2r
Copy link
Contributor Author

siv2r commented Jan 3, 2026

I can see that GitHub's file changes view shows only one file at a time due to the large number of changes. This is because the reference implementation includes dependencies and auxiliary materials:

  • The reference code uses secp256k1lab python library (vendored as a git subtree, ~20 files) for scalar and group arithmetic. I can remove this from the PR when the library is integrated into this repository (RFC: Integrate secp256k1lab v1.0.0 as subtree, use it for BIP-374 #1855).
  • Auxiliary files include docs/partialsig_forgery.md (which I can move to a gist if preferred) and a test vector generation script (~1400 lines). I can exclude these if necessary.

@murchandamus murchandamus changed the title Add BIP: FROST Signing for BIP340-compatible Threshold Signatures BIP Draft: FROST Signing Protocol for BIP340 Schnorr Signatures Jan 6, 2026
Copy link
Contributor

@murchandamus murchandamus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a first glance, but I noticed a few issues:

@murchandamus murchandamus changed the title BIP Draft: FROST Signing Protocol for BIP340 Schnorr Signatures BIP Draft: FROST Signing Protocol for BIP340 Signatures Jan 8, 2026
Copy link
Contributor

@murchandamus murchandamus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turn-around. It’s on my todo list to give this a more thorough look, but it might take a bit. If you can motivate some other reviewers meanwhile, that would also be welcome.

@siv2r
Copy link
Contributor Author

siv2r commented Jan 9, 2026

If you can motivate some other reviewers meanwhile, that would also be welcome.

I've shared it with most of the Bitcoin cryptographers I know and will post it on Twitter and the Bitcoin dev groups I'm part of. Hopefully that will bring in more reviewers!

Copy link

@DarkWindman DarkWindman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi! Quite a remarkable job! We found a few minor issues, and correcting them would improve the overall specification of the BIP.

Copy link
Contributor

@Christewart Christewart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned on X i'm working on this, so you will likely see more comments in the future. Another nice-to-have would be a table of contents (example) as most other BIPs have this. Perhaps this is a limitation of the .md document vs .mediawiki. Not sure.

- Let *pubshare* = *empty_bytestring*
- If the optional argument *thresh_pk* is not present:
- Let *thresh_pk* = *empty_bytestring*
- If the optional argument *m* is not present:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would you suggest handling this case in C?

We have a message m that equal to the empty bytestring? This is tested in the test vectors accompying this file here.

The python implmentation allows us to represent 2 different states that are (perhaps?) semantically mean the same thing is my understanding. Here are the 2 cases

  1. m is not present (encoded as 00) with the prefix
  2. m is present, but its the empty bytestring (encoded as 0100) with the prefix.

This can be represented in the type system of higher level languages like C++, Python, Rust, Scala etc.

From looking at the API on zkp, it seems like this wouldn't be possible to represent?

Copy link

@vitrixLab vitrixLab Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In C this must be modeled explicitly as an optional bytes type, otherwise we cannot distinguish
“m not present” (00) from “m present but empty(0100).

I recommend representing this as { uint8_t *ptr; size_t len; bool is_present; } and encoding based on is_present.

Collapsing these cases would break the test vectors and change the hash domain.

#sc

Copy link
Contributor Author

@siv2r siv2r Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We distinguish between a zeroed byte array and an empty byte string. Thus, m being absent (empty_bytestring) is different from m being equal to zero bytes (of any length), and we want to generate distinct nonces for these cases.

Yes, the current the zkp API doesn't add the message prefix correctly which needs to be fixed.

I agree with @vitrixLab, we can model this in C with an is_present variable, Musig2 does exactly this.

unsigned char msg_present;
msg_present = msg32 != NULL;
secp256k1_sha256_write(&sha, &msg_present, 1);
if (msg_present) {
    secp256k1_nonce_function_musig_helper(&sha, 8, msg32, 32);
}

@siv2r
Copy link
Contributor Author

siv2r commented Jan 21, 2026

Another nice-to-have would be a table of contents (example) as most other BIPs have this. Perhaps this is a limitation of the .md document vs .mediawiki. Not sure.

Yes, it's a .md issue, this bip initially had a manually written table of contents but was removed after #2070 (comment)

@murchandamus
Copy link
Contributor

As I mentioned on X i'm working on this, so you will likely see more comments in the future. Another nice-to-have would be a table of contents (example) as most other BIPs have this. Perhaps this is a limitation of the .md document vs .mediawiki. Not sure.

Click there. ;)

image

@Christewart
Copy link
Contributor

As I mentioned on X i'm working on this, so you will likely see more comments in the future. Another nice-to-have would be a table of contents (example) as most other BIPs have this. Perhaps this is a limitation of the .md document vs .mediawiki. Not sure.

Click there. ;)

Thank you! TIL :-)

Copy link

@DarkWindman DarkWindman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional minor issues and questions.

- For *i = 1 .. u*:
- Fail if not *0 ≤ id<sub>i</sub> ≤ n - 1*
- Fail if *cpoint(pubshare<sub>i</sub>)* fails
- Fail if *has_duplicates(id<sub>1..u</sub>)*
Copy link

@DarkWindman DarkWindman Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same function is also invoked in the DeriveInterpolatingValue() algorithm, which is called through the following chain:
ValidateSignersCtx()->DeriveThreshPubkey()->DeriveInterpolatingValue().
Is there a compelling reason to perform this check at the top level, or could the ID duplication check be retained solely at a lower level within DeriveInterpolatingValue()?

In addition, the ValidateSignersCtx() algorithm checks the condition t > n. However, as stated in the description, the valid range is 1 <= t <= n. Therefore, it would be clearer to replace the condition “Fail if t > n” with “Fail if not 1 <= t <= n”.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidateSignersCtx() checks the sanity of u signing participants (represented by ids and pubshares). So it only made sense to fail for all faults, like ids being out of range or containing duplicates. So, it's better to have it in the higher level?

I added has_duplicates again inside DeriveInterpolatingValue() because it has many other callers, just to be safe. Now that I look at it closely, all these calls get the ids & pubshares list after running ValidateSignersCtx() (through GetSessionValues), so we could technically remove the has_duplicates check from DeriveInterpolatingValue().

Therefore, it would be clearer to replace the condition “Fail if t > n” with “Fail if not 1 <= t <= n”.

I agree.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all these calls get the ids & pubshares list after running ValidateSignersCtx() (through GetSessionValues), so we could technically remove the has_duplicates check from DeriveInterpolatingValue().

Yes, I agree with you, since we call GetSessionValues() at the beginning of both functions that independently invoke DeriveInterpolatingValue(). Thus, we can remove the check from the DeriveInterpolatingValue() and keep it only in ValidateSignersCtx().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright. I'll update this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to keep this redundant check in DeriveInterpolatingValue() for now. Just looked at RFC 9591 which defines their derive_interpolating_value function as:

def derive_interpolating_value(L, x_i):
if x_i not in L:
raise "invalid parameters"
for x_j in L:
if count(x_j, L) > 1:
raise "invalid parameters"
...
...

We can remove this has_duplicates check later, if we really need to.

- If the optional argument *extra_in* is not present:
- Let *extra_in = empty_bytestring*
- Let *k<sub>i</sub> = scalar_from_bytes_wrapping(hash<sub>FROST/nonce</sub>(rand || bytes(1, len(pubshare)) || pubshare || bytes(1, len(thresh_pk)) || thresh_pk || m_prefixed || bytes(4, len(extra_in)) || extra_in || bytes(1, i - 1)))* for *i = 1,2*
- Fail if *k<sub>1</sub> = Scalar(0)* or *k<sub>2</sub> = Scalar(0)*

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While reading the implementation, I noticed that it includes a check ensuring that k_1 != k_2. At first glance, omitting this check does not appear to introduce any vulnerabilities, and we have verified this. However, I would appreciate hearing your opinion on this point.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which function are you referring to? I don't see a k_1 != k_2 check in the nonce_gen_internal or deterministic_sign functions.

This is an interesting question though. I never considered adding this check, primarily because BIP327's reference implementation doesn't have it either.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which function are you referring to? I don't see a k_1 != k_2 check in the nonce_gen_internal or deterministic_sign functions.

I apologize for not mentioning which implementation I was referring to. I meant the secp256k1-zkp FROST module, where the secp256k1_frost_nonce_gen() function performs this check. However, I think this verification is redundant, as I have not found any paper or specification that requires it. At first glance, it may seem that manipulation with Wagner attacks could apply, but I do not see any concrete attack vectors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I was not aware of this. Thank you! I checked the Olaf paper as well, and it didn't have this requirement. I haven't thought about this from security proof perspective. Will keep this open till then :)

@siv2r
Copy link
Contributor Author

siv2r commented Jan 25, 2026

@DarkWindman thanks a lot for the review! I've addressed most of your review comments in a88f033.

Copy link
Contributor

@murchandamus murchandamus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From an editorial standpoint, it looks pretty good and like all the required sections are present. I have read the proposal only partially, and do not have the expertise to fully understand all aspects, so I cannot comment on the technical soundness and whether the Specification is complete and sufficient.

@murchandamus murchandamus added the PR Author action required Needs updates, has unaddressed review comments, or is otherwise waiting for PR author label Jan 27, 2026

## Motivation

<!-- REVIEW: Should we add a paragraph about `OP_CHECKSIGADD` like BIP327 does? -->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Should we add a paragraph about OP_CHECKSIGADD like BIP327 does?"

Sounds reasonable to add that. If I'm not missing anything, the two paragraphs in BIP327 would fully apply to FROST as well (after minor adaptions, s/MuSig2/FROST/ and s/n-of-n/t-of-n/).

Comment on lines +110 to +111
This treatment may be confusing for readers familiar with the MuSig2 paper.
However, serialization is a technical detail that is irrelevant for users of MuSig2 interfaces.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should "MuSig2" replaced with "FROST" here?

| Notation | secp256k1lab | Description |
| --- | --- | --- |
| *p* | *FE.SIZE* | Field element size |
| *ord* | *GE.ORDER* | Group order |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the constant is also available as Scalar.SIZE in secp256k1lab

| *ord* | *GE.ORDER* | Group order |
| *G* | *G* | The secp256k1 generator point |
| *inf_point* | *GE()* | The infinity point |
| *is_infinity(P)* | *P.infinity()* | Returns whether *P* is the point at infinity |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| *is_infinity(P)* | *P.infinity()* | Returns whether *P* is the point at infinity |
| *is_infinity(P)* | *P.infinity* | Returns whether *P* is the point at infinity |

(since infinity is a property rather than a method within GE)

Comment on lines +350 to +351
- The accumulated tweak *tacc*: a *Scalar*
- The value *gacc*: *Scalar(1)* or *Scalar(-1)*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pedantic nit: could swap these two lines, so the order matches the one of unpacking tweak_ctx (and the order of TweakCtxInit return values) below

- Fail if *k<sub>1</sub> = Scalar(0)* or *k<sub>2</sub> = Scalar(0)*
- Let *R<sub>\*,1</sub> = k<sub>1</sub> &middot; G*, *R<sub>\*,2</sub> = k<sub>2</sub> &middot; G*
- Let *pubnonce = cbytes(R<sub>\*,1</sub>) || cbytes(R<sub>\*,2</sub>)*
- Let *secnonce = bytes(32, k<sub>1</sub>) || bytes(32, k<sub>2</sub>)*[^secnonce-ser]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could use scalar_to_bytes here instead

- Inputs:
- The number *u* of signing participants: an integer with *t ≤ u ≤ n*
- The list of participant public nonces *pubnonce<sub>1..u</sub>*: *u* 66-byte array, each an output of *NonceGen*
- The list of participant identifiers *id<sub>1..u</sub>*: *u* integers, each with 0 ≤ *id<sub>i</sub>* < *n*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pedantic nit, for consistency with other functions:

Suggested change
- The list of participant identifiers *id<sub>1..u</sub>*: *u* integers, each with 0 ≤ *id<sub>i</sub>* < *n*
- The list of participant identifiers *id<sub>1..u</sub>*: *u* integers, each with 0 ≤ *id<sub>i</sub>* <= *n-1*


- Inputs:
- The partial signature *psig*: a 32-byte array, serialized scalar
- The list public nonces *pubnonce<sub>1..u</sub>*: *u* 66-byte arrays, each an output of *NonceGen*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- The list public nonces *pubnonce<sub>1..u</sub>*: *u* 66-byte arrays, each an output of *NonceGen*
- The list of public nonces *pubnonce<sub>1..u</sub>*: *u* 66-byte arrays, each an output of *NonceGen*

],
"msg_index": 0,
"signer_index": 0,
"comment": "The signer's pubshare is not in the list of pubshares"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this test should be moved into sign_error_test_cases since this would cause an error when deriving the threshold public key from the pubshares?

https://github.com/bitcoin/bips/blob/ec46a20323840b1a6aba83bc2d18b34dd0811245/bip-frost-signing.md#signers-context

Fail if DeriveThreshPubkey(id1..u, pubshare1..u) ≠ thresh_pk

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

New BIP PR Author action required Needs updates, has unaddressed review comments, or is otherwise waiting for PR author

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants